<?PHP
#
#   FILE:  Mailer.php
#
#   A plugin for the Metavus digital collections platform
#   Copyright 2011-2021 Edward Almasy and Internet Scout Research Group
#   http://metavus.net
#
# @scout:phpstan

namespace Metavus\Plugins;

use InvalidArgumentException;
use Metavus\MetadataSchema;
use Metavus\Plugins\Mailer\StoredEmail;
use Metavus\Plugins\Rules\Rule;
use Metavus\Plugins\Rules\RuleFactory;
use Metavus\Record;
use Metavus\SystemConfiguration;
use Metavus\User;
use ScoutLib\ApplicationFramework;
use ScoutLib\Database;
use ScoutLib\Email;
use ScoutLib\Plugin;

/**
 * Plugin for providing a common mechanism for sending emails to users
 * using templates.
 */
class Mailer extends Plugin
{
    # ---- STANDARD PLUGIN INTERFACE -----------------------------------------

    /**
     * Set the plugin attributes.  At minimum this method MUST set $this->Name
     * and $this->Version.  This is called when the plugin is initially loaded.
     */
    public function register()
    {
        $this->Name = "Mailer";
        $this->Version = "1.4.0";
        $this->Description = "Generates and emails messages to users based on templates.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = ["MetavusCore" => "1.0.0"];
        $this->EnabledByDefault = true;

        $this->CfgSetup["BaseUrl"] = [
            "Type" => "Text",
            "Label" => "Base URL",
            "Help" => "This value overrides any automatically-determined"
                ." value for the X-BASEURL-X template keyword.",
        ];

        $this->CfgSetup["EmailTaskPriority"] = [
            "Type" => "Option",
            "Label" => "E-mail Task Priority",
            "Help" => "Priority of the e-mail sending tasks when using the task queue.",
            "AllowMultiple" => false,
            "Default" => ApplicationFramework::PRIORITY_LOW,
            "Options" => [
                ApplicationFramework::PRIORITY_BACKGROUND => "Background",
                ApplicationFramework::PRIORITY_LOW => "Low",
                ApplicationFramework::PRIORITY_MEDIUM => "Medium",
                ApplicationFramework::PRIORITY_HIGH => "High"
            ]
        ];
    }

    /**
     * Perform any work needed when the plugin is first installed (for example,
     * creating database tables).
     * @return string|null NULL if installation succeeded, otherwise a string containing
     *       an error message indicating why installation failed.
     */
    public function install()
    {
        $this->configSetting("Templates", []);
        $Result = $this->createTables($this->SqlTables);
        if ($Result !== null) {
            return $Result;
        }
        $this->addDefaultTemplates();
        return null;
    }

    /**
     * Load default templates for standard site emails
    */
    public function addDefaultTemplates()
    {
        $MailerTemplates = $this->getTemplateList();

        $TemplatesNeeded = [
            "Email Change Default" => [
                "Body" => "Someone (presumably you) has requested that the email "
                ."address registered at X-PORTALNAME-X ".
                "for the account X-USERNAME-X be changed from X-EMAILADDRESS-X "
                ."to X-NEWEMAILADDRESS-X.\n\n".
                "To confirm this change, please click on this link:\n\n".
                "   X-CHANGEURL-X\n\n".
                "If that link doesn't work you can also go to this address:\n\n".
                "   X-MANUALCHANGEURL-X\n\n".
                "And enter your user name and confirmation code:\n\n".
                "   Name: X-USERNAME-X\n\n".
                "   Code: X-CHANGECODE-X\n\n".
                "If you don't want to change your email, just ignore this ".
                "message and nothing will be done.\n\n".
                "For reference, the password change request came from address X-IPADDRESS-X ".
                "and this e-mail was sent to X-NEWEMAILADDRESS-X",
                "Subject" => "X-PORTALNAME-X mail address change request",
                "FunctionName" => "EmailChangeTemplateId"
            ],
            "Account Activation Default" => [
                "Body" => "Thank you for signing up on X-PORTALNAME-X!\n\n".
                "To activate your new login, please click on the link below:\n\n".
                "   X-ACTIVATIONURL-X\n\n".
                "If the link doesn't work you can also go to this address:\n\n".
                "   X-MANUALACTIVATIONURL-X\n\n".
                "And enter your user name and activation code:\n\n".
                "   Name: X-USERNAME-X\n\n".
                "   Code: X-ACTIVATIONCODE-X\n\n".
                "We're looking forward to seeing you online!",
                "Subject" => "New Login for X-PORTALNAME-X (X-USERNAME-X)",
                "FunctionName" => "ActivateAccountTemplateId"
            ],
            "Password Change Default" => [
                "Body" => "Someone (presumably you) has asked to change your password on ".
                "X-PORTALNAME-X for the account X-USERNAME-X.\n\n".
                "To change your password, please click on this link:\n\n".
                "   X-RESETURL-X\n\n".
                "If that link doesn't work you can also go to this address:\n\n".
                "   X-MANUALRESETURL-X\n\n".
                "And enter your user name and password reset code:\n\n".
                "   Name: X-USERNAME-X\n\n".
                "   Code: X-RESETCODE-X\n\n".
                "Your password will remain the same until changed via one of the above links.\n\n".
                "For reference, the password change request came from address X-IPADDRESS-X ".
                "and this e-mail was sent to X-EMAILADDRESS-X.",
                "Subject" => "X-PORTALNAME-X Password Change",
                "FunctionName" => "PasswordChangeTemplateId"
            ]
        ];

        foreach ($TemplatesNeeded as $Name => $Template) {
            if (!in_array($Name, $MailerTemplates)) {
                $BodyWithHtmlBreaks = nl2br($Template["Body"]);
                foreach ([
                    "X-RESETURL-X",
                    "X-MANUALRESETURL-X",
                    "X-CHANGEURL-X",
                    "X-MANUALCHANGEURL-X"
                ] as $UrlText) {
                    $BodyWithHtmlBreaks = str_replace(
                        $UrlText,
                        "<a href='" . $UrlText . "'>" . $UrlText . "</a>",
                        $BodyWithHtmlBreaks
                    );
                }
                $Function = $Template["FunctionName"];
                $TemplateId = $this->addTemplate(
                    $Name,
                    "X-PORTALNAME-X <X-ADMINEMAIL-X>",
                    $Template["Subject"],
                    $BodyWithHtmlBreaks,
                    "",
                    $Template["Body"],
                    ""
                );
                SystemConfiguration::getInstance()->setInt($Function, $TemplateId);
            }
        }
    }


    /**
     * Upgrade from a previous version.
     * @param string $PreviousVersion Previous version number.
     */
    public function upgrade(string $PreviousVersion)
    {
        # upgrade to 1.0.1
        if (version_compare($PreviousVersion, "1.0.1", "<")) {
            # get the current list of templates
            $Templates = $this->configSetting("Templates");

            # add the "CollapseBodyMargins" setting
            foreach ($Templates as $Id => $Template) {
                $Templates[$Id]["CollapseBodyMargins"] = false;
            }

            # set the updated templates
            $this->configSetting("Templates", $Templates);
        }

        # upgrade to 1.0.2
        if (version_compare($PreviousVersion, "1.0.2", "<")) {
            # get the current list of templates
            $Templates = $this->configSetting("Templates");

            # add the plain text fields
            foreach ($Templates as $Id => $Template) {
                $Templates[$Id]["PlainTextBody"] = "";
                $Templates[$Id]["PlainTextItemBody"] = "";
            }

            # set the updated templates
            $this->configSetting("Templates", $Templates);
        }

        # upgrade to 1.0.6
        if (version_compare($PreviousVersion, "1.0.6", "<")) {
            $this->configSetting(
                "EmailTaskPriority",
                ApplicationFramework::PRIORITY_BACKGROUND
            );
        }

        # upgrade to 1.3.0
        if (version_compare($PreviousVersion, "1.3.0", "<")) {
            $Templates = $this->configSetting("Templates");
            foreach ($Templates as &$Template) {
                $Template["ConfirmMode"] = false;
                $Template["EmailPerResource"] = false;
            }
            $this->configSetting("Templates", $Templates);

            $Result = $this->createTables($this->SqlTables);
            if ($Result !== null) {
                return $Result;
            }
        }

        # upgrade to 1.3.1
        if (version_compare($PreviousVersion, "1.3.1", "<")) {
            $Templates = $this->configSetting("Templates");
            $DB = new Database();

            # if Rules was enabled and is new enough that it had the
            # Rules_Rules table
            if ($GLOBALS["G_PluginManager"]->pluginEnabled("Rules") &&
                $DB->tableExists("Rules_Rules")) {
                # pull in objects we need from Rules
                # (these won't be autoloaded because Rules isn't initialized yet)
                require_once("plugins/Rules/objects/Rule.php");
                require_once("plugins/Rules/objects/RuleFactory.php");

                # build a mapping of templates to the rules that use them
                $TemplateRuleMap = [];
                $RFactory = new RuleFactory();
                foreach ($RFactory->getItems() as $RuleId => $Rule) {
                    if ($Rule->action() == Rule::ACTION_SENDEMAIL) {
                        $ActionParams = $Rule->actionParameters();
                        $TemplateRuleMap[$ActionParams["Template"]][] = $RuleId;
                    }
                }

                # iterate over all the templates, moving the
                # ConfirmMode from the template into the corresponding
                # rule if necessary
                foreach ($Templates as $TId => $Template) {
                    if (array_key_exists($TId, $TemplateRuleMap) &&
                        array_key_exists("ConfirmMode", $Template) &&
                        $Template["ConfirmMode"]) {
                        foreach ($TemplateRuleMap[$TId] as $RuleId) {
                            $Rule = new Rule($RuleId);
                            $ActionParams = $Rule->actionParameters();
                            $ActionParams["ConfirmBeforeSending"] = true;
                            $Rule->actionParameters($ActionParams);
                        }
                    }
                }
            }

            # remove ConfirmMode setting from templates
            foreach ($Templates as &$Template) {
                if (array_key_exists("ConfirmMode", $Template)) {
                    unset($Template["ConfirmMode"]);
                }
            }
            $this->configSetting("Templates", $Templates);
        }

        return null;
    }

    /**
     * Hook the events into the application framework.
     * @return array Returns an array of events to be hooked into the application
     *      framework.
     */
    public function hookEvents()
    {
        return ["EVENT_COLLECTION_ADMINISTRATION_MENU" => "AddCollectionAdminMenuItems"];
    }

    /**
     * Declare events.
     *
     * Mailer_EVENT_IS_TEMPLATE_IN_USE -- CHAIN event with parameters
     *   (int $TemplateId, array $TemplateUsers). Plugins making use of
     *   a given $TemplateId are expected to append a string describing
     *   where they use a template, ideally one including their
     *   $this->Name.
     *
     * Mailer_EVENT_MODIFY_KEYWORD_REPLACEMENTS -- CHAIN event with
     *   parameters (int $TemplateId, array $Resources, User|string
     *   $Recipient, array $Replacements). Plugins wishing to override
     *   or extend the replacements used when $TemplateId generates a
     *   message for $Recipient should add to the values in
     *   $Replacements. Array keys give the keyword (without the X- -X
     *   prefix/suffix) and values give the desired replacement.
     *
     * Mailer_EVENT_BEFORE_MESSAGE_SEND -- CHAIN event with parameters
     *  (Email $Message, array $Resources, int $TemplateId) that is signaled
     *  immediately before $Message is sent. Methods of $Message can be used
     *  to make any necessary modifications to $Message prior to sending it.
     *
     * @return array an array of the events this plugin provides.
     */
    public function declareEvents()
    {
        return [
            "Mailer_EVENT_IS_TEMPLATE_IN_USE" =>
                ApplicationFramework::EVENTTYPE_CHAIN,
            "Mailer_EVENT_MODIFY_KEYWORD_REPLACEMENTS" =>
                ApplicationFramework::EVENTTYPE_CHAIN,
            "Mailer_EVENT_BEFORE_MESSAGE_SEND" =>
                ApplicationFramework::EVENTTYPE_CHAIN,
        ];
    }


    # ---- HOOKED METHODS ----------------------------------------------------

    /**
     * Add entries to the Collection Administration menu.
     * @return array List of entries to add, with the label as the value and
     *       the page to link to as the index.
     */
    public function addCollectionAdminMenuItems()
    {
        return [
            "EditMessageTemplates" => "Edit Email Templates",
            "ListQueuedEmail" => "Mailer Approval Queue"
        ];
    }

    # ---- CALLABLE METHODS --------------------------------------------------

    /**
     * Retrieve list of currently available templates, with template IDs for
     * the indexes and template names for the values.
     * @return Array containing template names.
     */
    public function getTemplateList()
    {
        $Templates = $this->configSetting("Templates");
        $TemplateList = [];
        if (count($Templates) > 0) {
            foreach ($Templates as $Id => $Template) {
                $TemplateList[$Id] = $Template["Name"];
            }
        }
        return $TemplateList;
    }

    /**
     * Add or update a template according to passed parameters.
     * the indexes and template names for the values.
     * @param string $Name The name of the template.
     * @param string $From The from parameter for the template
     * @param string $Subject The subject of the email to send.
     * @param string $Body The body of the email to send.
     * @param string $ItemBody The body of each individual iterable to send.
     * @param string $PlainTextBody Non-HTML version of the body
     * @param string $PlainTextItemBody Non-HTML version of the item body
     * @param string $Headers Any additional headers to send.  (OPTIONAL)
     * @param bool $CollapseBodyMargins Whether or not the margins should
     *      be collapsed.  (OPTIONAL, defaults to FALSE)
     * @param bool $EmailPerResource TRUE to send one message per resource,
     *      rather than a single message that contains a list of
     *      resources.  (OPTIONAL, defaults to FALSE)
     * @return Integer of template id.
     */
    public function addTemplate(
        $Name,
        $From,
        $Subject,
        $Body,
        $ItemBody,
        $PlainTextBody,
        $PlainTextItemBody,
        $Headers = "",
        $CollapseBodyMargins = false,
        $EmailPerResource = false
    ) {
        $G_Templates = $this->configSetting("Templates");

        # get next template ID
        if (($G_Templates === null) || (count($G_Templates) == 0)) {
            $TemplateId = 0;
        } else {
            $TemplateId = (int)max(array_keys($G_Templates)) + 1;
        }
        $G_Templates[$TemplateId] = [];
        $G_Templates[$TemplateId]["Name"] = $Name;
        $G_Templates[$TemplateId]["From"] = $From;
        $G_Templates[$TemplateId]["Subject"] = $Subject;
        $G_Templates[$TemplateId]["Body"] = $Body;
        $G_Templates[$TemplateId]["ItemBody"] = $ItemBody;
        $G_Templates[$TemplateId]["PlainTextBody"] = $PlainTextBody;
        $G_Templates[$TemplateId]["PlainTextItemBody"] = $PlainTextItemBody;
        $G_Templates[$TemplateId]["Headers"] = $Headers;
        $G_Templates[$TemplateId]["CollapseBodyMargins"] = $CollapseBodyMargins;
        $G_Templates[$TemplateId]["EmailPerResource"] = $EmailPerResource;

        $this->configSetting("Templates", $G_Templates);
        return $TemplateId;
    }

    /**
     * Retrieve a Mailer template.
     * @param int $Id Desired template ID.
     * @return array Template information with keys "Name", "From", "Subject",
     *   "Body", "ItemBody", "PlainTextBody", "PlainTextItemBody", "Headers",
     *   "CollapseBodyMargins", and "EmailPerResource"
     * @throws InvalidArgumentException if invalid template ID is provided.
     */
    public function getTemplate(int $Id)
    {
        $Templates = $this->configSetting("Templates");
        if (!isset($Templates[$Id])) {
            throw new InvalidArgumentException(
                "Invalid Template ID :".$Id
            );
        }

        return $Templates[$Id];
    }

    /**
     * Update a Mailer template.
     * @param int $Id Desired template ID.
     * @param array $TemplateData Updated template data. May contain a subset of the keys
     *   returned by getTemplate().
     * @throws InvalidArgumentException if invalid template ID is provided.
     * @throws InvalidArgumentException if an invalid key is provided in TemplateData.
     */
    public function updateTemplate(int $Id, array $TemplateData)
    {
        $Templates = $this->configSetting("Templates");

        # throw exception if invalid ID provided
        if (!isset($Templates[$Id])) {
            throw new InvalidArgumentException(
                "Invalid Template ID :".$Id
            );
        }

        $InvalidKeys = array_diff(
            array_keys($TemplateData),
            array_keys($Templates[$Id])
        );

        # throw exception if invalid data provided
        if (count($InvalidKeys)) {
            throw new InvalidArgumentException(
                "Invalid template settings provided: "
                .implode(", ", $InvalidKeys)
            );
        }

        # update data and save
        foreach ($TemplateData as $Key => $Val) {
            $Templates[$Id][$Key] = $Val;
        }
        $this->configSetting("Templates", $Templates);
    }

    /**
     * Send email to specified recipients using specified template.
     * @param int $TemplateId ID of template to use in generating emails.
     * @param array $Recipients User object or ID or email address, or an array
     *      of any of these to use as email recipients.
     * @param Record|array|int $Resources Resource or Resource ID or array of
     *      Resources or array of Resource IDs to be referred to within email
     *      messages.  (OPTIONAL)
     * @param array $ExtraValues Array of additional values to swap into template,
     *      with value keywords (without the "X-" and "-X") for the index
     *      and values for the values.  This parameter can be used to
     *      override the builtin keywords.  (OPTIONAL)
     * @param bool $ConfirmBeforeSending TRUE if messages should be queued for
     *      confirmation by an admin prior to sending. (OPTIONAL, default FALSE)
     * @return Number of email messages sent.
     */
    public function sendEmail(
        $TemplateId,
        $Recipients,
        $Resources = null,
        $ExtraValues = null,
        $ConfirmBeforeSending = false
    ) {
        # initialize count of emails sent
        $MessagesSent = 0;

        # convert incoming recipients to arrays (if not already)
        if (!is_array($Recipients)) {
            $Recipients = [ $Recipients ];
        }

        # if resources supplied
        if ($Resources !== null) {
            # convert incoming parameters to arrays (if not already)
            if (!is_array($Resources)) {
                $Resources = [ $Resources ];
            }

            # convert any resource IDs to resource objects
            if (count($Resources) && is_numeric(reset($Resources))) {
                $NewResources = [];
                foreach ($Resources as $Id) {
                    $NewResources[$Id] = new Record($Id);
                }
                $Resources = $NewResources;
            }
        }

        # retrieve appropriate template
        $Templates = $this->configSetting("Templates");
        $Template = $Templates[$TemplateId];

        # if this templates sends one email per resource and we have
        # more than one on our list, call ourselves recursively for each
        # single resource
        if ($Template["EmailPerResource"] && count($Resources) > 1) {
            foreach ($Resources as $Resource) {
                $MessagesSent += $this->sendEmail(
                    $TemplateId,
                    $Recipients,
                    $Resource,
                    $ExtraValues,
                    $ConfirmBeforeSending
                );
            }
        } else {
            # set up parameters for keyword replacement callback
            $this->KRItemBody = $Template["ItemBody"];
            $this->KRResources = $Resources;
            unset($this->KRCurrentResource);

            # for each recipient
            foreach ($Recipients as $Recipient) {
                $SignalResult = $GLOBALS["AF"]->signalEvent(
                    "Mailer_EVENT_MODIFY_KEYWORD_REPLACEMENTS",
                    [
                        "TemplateId" => $TemplateId,
                        "Resources" => $Resources,
                        "Recipient" => $Recipient,
                        "Replacements" => $ExtraValues,
                    ]
                );
                $this->KRExtraValues = $SignalResult["Replacements"];

                # convert recipient to User if User ID supplied
                if (is_numeric($Recipient)) {
                    if (!User::itemExists((int)$Recipient)) {
                        continue;
                    }
                    $Recipient = new User($Recipient);
                    if ($Recipient->hasPriv(PRIV_USERDISABLED)) {
                        continue;
                    }
                }

                # if recipient is User object
                if ($Recipient instanceof User) {
                    # retrieve destination address from user
                    $Address = trim($Recipient->get("EMail"));

                    # set up per-user parameters for keyword replacement callback
                    $this->KRUser = $Recipient;
                } else {
                    # assume recipient is just destination address
                    $Address = $Recipient;

                    # clear per-user parameters for keyword replacement callback
                    unset($this->KRUser);
                }

                # get the message subject
                $Subject = trim($this->replaceKeywords($Template["Subject"]));

                # skip if there is no destination address or no subject
                if (!strlen($Address) || !strlen($Subject)) {
                    continue;
                }

                # create and set up the message to send
                $Msg = new Email();
                $Msg->addLogData([
                    "TemplateId" => $TemplateId,
                ]);
                $Msg->charSet($GLOBALS["AF"]->htmlCharset());
                $Msg->from($this->replaceKeywords($Template["From"]));
                $Msg->to($Address);
                $Msg->subject($Subject);

                # set up headers for message
                $Headers = [];
                $Headers[] = "Auto-Submitted: auto-generated";
                $Headers[] = "Precedence: list";
                $Headers[] = "X-SiteUrl: ". ApplicationFramework::baseUrl();
                if (strlen($Template["Headers"])) {
                    $ExtraHeaders = $this->splitByLineEnding($Template["Headers"]);
                    foreach ($ExtraHeaders as $Line) {
                        if (strlen(trim($Line))) {
                            $Headers[] = $this->replaceKeywords($Line);
                        }
                    }
                }

                # add the headers to the message
                $Msg->addHeaders($Headers);

                # check if we have an html body
                if (strlen(trim(strip_tags($Template["Body"]))) == 0) {
                    # if there was no html body, then construct one based
                    # on the plain text body, replacing keywords as we go
                    $Body = "<pre>"
                        .$this->replaceKeywordsInBody(
                            $Template["PlainTextBody"],
                            false
                        )
                        ."</pre>";
                } else {
                    # otherwise, just do keyword replacement on the HTML body
                    $Body = $this->replaceKeywordsInBody($Template["Body"]);
                }

                # if body contains HTML
                if (strlen(strip_tags($Body)) != strlen($Body)) {
                    $Msg->isHtml(true);

                    # wrap HTML where necessary to keep it below 998 characters
                    $Body = Email::wrapHtmlAsNecessary($Body);

                    # construct the style attribute necessary for collapsing the body
                    # margins, if instructed to do so
                    $MarginsStyle = $Template["CollapseBodyMargins"]
                        ? ' style="margin:0;padding:0;"' : "";

                    # wrap the message in boilerplate HTML
                    $Body = '<!DOCTYPE html>
                     <html lang="en"'.$MarginsStyle.'>
                       <head><meta charset="'.$GLOBALS["AF"]->htmlCharset().'" /></head>
                       <body'.$MarginsStyle.'>'.$Body.'</body>
                     </html>';
                }

                # add the body to the message
                $Msg->body($Body);

                # plain text body and item body are set
                if (strlen(trim($Template["PlainTextBody"])) > 0) {
                    $this->KRItemBody = $Template["PlainTextItemBody"];

                    # start with the body from the template and replace keywords
                    $PlainTextBody = $this->replaceKeywordsInBody(
                        $Template["PlainTextBody"],
                        false
                    );

                    # wrap body where necessary to keep it below 998 characters
                    $PlainTextBody = wordwrap($PlainTextBody, 998, "\n", true);

                    # add the alternate body to the message
                    $Msg->alternateBody($PlainTextBody);
                }

                $Result = $GLOBALS["AF"]->signalEvent(
                    "Mailer_EVENT_BEFORE_MESSAGE_SEND",
                    [
                        "Message" => $Msg,
                        "Resources" => $Resources,
                        "TemplateId" => $TemplateId,
                    ]
                );
                $Msg = $Result["Message"];

                # send message to the user
                if ($ConfirmBeforeSending) {
                    StoredEmail::create(
                        $Msg,
                        $Resources,
                        $TemplateId
                    );
                } else {
                    $MsgResult = $Msg->send();

                    if (!$MsgResult) {
                        $StoredMsg = StoredEmail::create(
                            $Msg,
                            $Resources,
                            $TemplateId
                        );

                        $Templates = $this->getTemplateList();
                        $TemplateName = $Templates[$TemplateId];

                        $GLOBALS["AF"]->logMessage(
                            ApplicationFramework::LOGLVL_ERROR,
                            "Mailer: Unable to send email to "
                            .$Msg->to()
                            ." Using Template \"".$TemplateName."\" ("
                            .$TemplateId.")."
                            ." Email queued with ID ".$StoredMsg->id()."."
                        );
                    }
                }
                $MessagesSent++;
            }
        }

        # report number of emails sent back to caller
        return $MessagesSent;
    }

    /**
     * Send email to specified recipients using specified template but use
     * background tasks instead of trying to send all to all of the
     * recipients at once.
     * @param int $TemplateId ID of template to use in generating emails.
     * @param mixed $Recipients User object or ID or email address, or an array
     *       of any of these to use as email recipients.
     * @param mixed $Resources Resource or Resource ID or array of Resources or
     *       array of Resource IDs to be referred to within email messages.
     *       (OPTIONAL, defaults to NULL)
     * @param array $ExtraValues Array of additional values to swap into template,
     *       with value keywords (without the "X-" and "-X") for the index
     *       and values for the values.  This parameter can be used to
     *       override the builtin keywords.  (OPTIONAL)
     * @param bool $ConfirmBeforeSending TRUE if messages should be queued for confirmation
     *       by an admin prior to sending. (OPTIONAL, default FALSE)
     * @return int Number of email messages to be sent.
     */
    public function sendEmailUsingTasks(
        $TemplateId,
        $Recipients,
        $Resources = [],
        $ExtraValues = null,
        $ConfirmBeforeSending = false
    ) {
        # retrieve appropriate template and its name
        $Templates = $this->configSetting("Templates");

        # don't send e-mail if the template is invalid
        if (!isset($Templates[$TemplateId])) {
            return 0;
        }

        # convert incoming parameters to arrays if necessary
        if (!is_array($Recipients)) {
            $Recipients = [$Recipients];
        }
        if ($Resources && !is_array($Resources)) {
            $Resources = [$Resources];
        }

        # load resource IDs if necessary because they're better for serializing
        # for the unique task
        if (count($Resources) && is_object(reset($Resources))) {
            $NewResources = [];
            foreach ($Resources as $Resource) {
                $NewResources[] = $Resource->id();
            }

            $Resources = $NewResources;
        }

        # get the name of the template for the task description
        $TemplateName = $Templates[$TemplateId]["Name"];

        # variable to track how many e-mails are to be sent
        $NumToBeSent = 0;

        # for each user
        foreach ($Recipients as $Recipient) {
            # skip invalid users
            if (is_numeric($Recipient)) {
                if (!User::itemExists((int)$Recipient)) {
                    continue;
                }
                $Recipient = new User($Recipient);
            }

            # build task description
            $RecipientEmail = ($Recipient instanceof User)
                    ? $Recipient->get("EMail") : $Recipient;
            $TaskDescription = "Send e-mail to \""
                    .$RecipientEmail."\" using the \""
                    .$TemplateName."\" template.";

            # use user IDs rather than user objects because they are
            #       smaller to serialize when queueing tasks
            if (is_object($Recipient) && method_exists($Recipient, "id")) {
                $Recipient = $Recipient->id();
            }

            # queue the unique task
            $GLOBALS["AF"]->queueUniqueTask(
                [$this, "SendEmail"],
                [
                    $TemplateId,
                    $Recipient,
                    $Resources,
                    $ExtraValues,
                    $ConfirmBeforeSending
                ],
                $this->configSetting("EmailTaskPriority"),
                $TaskDescription
            );

            # increment the number of e-mail messages to be sent
            $NumToBeSent += $Templates[$TemplateId]["EmailPerResource"] ?
                count($Resources) : 1;
        }

        # return the number to be sent
        return $NumToBeSent;
    }

    /**
     * Get a list of template users.
     * @param int $TemplateId Template to check.
     * @return array of strings identifying template users.
     */
    public function findTemplateUsers($TemplateId)
    {
        $Result = $GLOBALS["AF"]->signalEvent(
            "Mailer_EVENT_IS_TEMPLATE_IN_USE",
            [
                "TemplateId" => $TemplateId,
                "TemplateUsers" => []
            ]
        );

        return $Result["TemplateUsers"];
    }

    # ---- PRIVATE INTERFACE -------------------------------------------------

    # values for use by KeywordReplacmentCallback()
    private $KRUser;
    private $KRItemBody;
    private $KRResources;
    private $KRCurrentResource;
    private $KRExtraValues;
    private $KRIsHtml;

    /**
     * Replace keywords in the given body text.
     * @param string $Body Body text.
     * @param bool $IsHtml Set to TRUE to escape necessary characters in keywords.
     * @return string Returns the body text with keywords replaced.
     */
    protected function replaceKeywordsInBody($Body, $IsHtml = true)
    {
        $NewBody = "";

        # flag whether the output is HTML
        $this->KRIsHtml = $IsHtml;

        # for each line of template
        foreach ($this->splitByLineEnding($Body) as $Line) {
            # replace any keywords in line and add line to message body
            # along with a newline to avoid line truncation by mail transfer
            # agents
            $NewBody .= $this->replaceKeywords($Line) . "\n";
        }

        return $NewBody;
    }

    /**
     * Split a string by its line endings, e.g., CR, LF, or CRLF.
     * @param string $Value String to split.
     * @return array Returns the string split by its line endings.
     */
    protected function splitByLineEnding($Value)
    {
        $RetVal = preg_split('/\r\n|\r|\n/', $Value);
        return ($RetVal === false) ? [] : $RetVal;
    }

    /**
     * Process keyword replacements.
     * @param string $Line Text to process.
     * @return string modified text.
     */
    private function replaceKeywords($Line)
    {
        return preg_replace_callback(
            "/X-([A-Z0-9:]+)-X/",
            [$this, "keywordReplacementCallback"],
            $Line
        );
    }

    /**
     * Callback to handle keyword repacements within a message.
     * @param array $Matches Array of matching keywords.
     * @return string modified text.
     */
    private function keywordReplacementCallback($Matches)
    {
        static $FieldNameMappings;
        static $StdFieldNameMappings;
        static $InResourceList = false;
        static $ResourceNumber;

        # if extra value was supplied with keyword that matches the match string
        if (is_array($this->KRExtraValues)
                && isset($this->KRExtraValues[$Matches[1]])) {
            # return extra value to caller
            return $this->KRExtraValues[$Matches[1]];
        }

        # if current resource is not yet set then set default value if available
        if (!isset($this->KRCurrentResource) && count($this->KRResources)) {
            $this->KRCurrentResource = reset($this->KRResources);
        }

        # start out with assumption that no replacement text will be found
        $Replacement = $Matches[0];

        # switch on match string
        switch ($Matches[1]) {
            case "PORTALNAME":
                $Replacement = SystemConfiguration::getInstance()->getString("PortalName");
                break;

            case "BASEURL":
                $Replacement = strlen(trim($this->configSetting("BaseUrl")))
                        ? trim($this->configSetting("BaseUrl"))
                        : ApplicationFramework::baseUrl()."index.php";
                break;

            case "ADMINEMAIL":
                $Replacement = SystemConfiguration::getInstance()->getString("AdminEmail");
                break;

            case "LEGALNOTICE":
                $Replacement = SystemConfiguration::getInstance()->getString("LegalNotice");
                break;

            case "USERLOGIN":
            case "USERNAME":
                if (isset($this->KRUser)) {
                    $Value = $this->KRUser->get("UserName");
                    $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                }
                break;

            case "USERREALNAME":
                if (isset($this->KRUser)) {
                    $Value = $this->KRUser->get("RealName");

                    # if the user hasn't specified a full name
                    if (!strlen(trim($Value))) {
                        $Value = $this->KRUser->get("UserName");
                    }

                    $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                }
                break;

            case "USEREMAIL":
                if (isset($this->KRUser)) {
                    $Value = $this->KRUser->get("EMail");
                    $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                }
                break;

            case "RESOURCELIST":
                $Replacement = "";
                if ($InResourceList == false) {
                    $InResourceList = true;
                    $ResourceNumber = 1;
                    foreach ($this->KRResources as $Resource) {
                        $this->KRCurrentResource = $Resource;
                        $TemplateLines = $this->splitByLineEnding($this->KRItemBody);
                        foreach ($TemplateLines as $Line) {
                            $Replacement .= preg_replace_callback(
                                "/X-([A-Z0-9:]+)-X/",
                                [$this, "KeywordReplacementCallback"],
                                $Line
                            ) . "\n";
                        }
                        $ResourceNumber++;
                    }
                    $InResourceList = false;
                }
                break;

            case "RESOURCENUMBER":
                $Replacement = $ResourceNumber;
                break;

            case "RESOURCECOUNT":
                $Replacement = count($this->KRResources);
                break;

            case "RESOURCEID":
                $Replacement = $this->KRCurrentResource->id();
                break;

            case "RESOURCEVIEWURL":
                static $Schemas = [];
                static $BaseUrl;

                # retrieve view page for schema used for resource
                $SchemaId = $this->KRCurrentResource->getSchemaId();
                if (!array_key_exists($SchemaId, $Schemas)) {
                    $Schemas[$SchemaId] = new MetadataSchema($SchemaId);
                }
                $ViewPage = $Schemas[$SchemaId]->viewPage();

                # make sure view page will work for clean URL substitution
                if (strpos($ViewPage, "index.php") !== 0) {
                    $ViewPage = "index.php".$ViewPage;
                }

                # insert resource ID into view page
                $ViewPage = preg_replace(
                    "%\\\$ID%",
                    $this->KRCurrentResource->id(),
                    $ViewPage
                );

                # get any clean URL for view page
                $ViewPage = $GLOBALS["AF"]->getCleanRelativeUrlForPath($ViewPage);

                # add base URL to view page
                if (!isset($BaseUrl)) {
                    $BaseUrl = strlen(trim($this->configSetting("BaseUrl")))
                            ? trim($this->configSetting("BaseUrl"))
                            : ApplicationFramework::baseUrl();
                }
                $Replacement = $BaseUrl.$ViewPage;
                break;

            default:
                # map to date/time values if appropriate
                $DateFormats = [
                    "DATE"            => "M j Y",
                    "TIME"            => "g:ia T",
                    "YEAR"            => "Y",
                    "YEARABBREV"      => "y",
                    "MONTH"           => "n",
                    "MONTHNAME"       => "F",
                    "MONTHABBREV"     => "M",
                    "MONTHZERO"       => "m",
                    "DAY"             => "j",
                    "DAYZERO"         => "d",
                    "DAYWITHSUFFIX"   => "jS",
                    "WEEKDAYNAME"     => "l",
                    "WEEKDAYABBREV"   => "D",
                    "HOUR"            => "g",
                    "HOURZERO"        => "h",
                    "MINUTE"          => "i",
                    "TIMEZONE"        => "T",
                    "AMPMLOWER"       => "a",
                    "AMPMUPPER"       => "A",
                ];

                # map the share format values if appropriate
                if ($GLOBALS["G_PluginManager"]->pluginEnabled("SocialMedia")) {
                    $ShareFormats = ["FACEBOOK", "TWITTER", "LINKEDIN"];
                } else {
                    $ShareFormats = [];
                }

                if (isset($DateFormats[$Matches[1]])) {
                    $Replacement = date($DateFormats[$Matches[1]]);
                } elseif (isset($this->KRCurrentResource)) {
                    # get the schema ID for the resource
                    $SchemaId = $this->KRCurrentResource->getSchemaId();

                    # load field name mappings (if not already loaded)
                    if (!isset($FieldNameMappings[$SchemaId])) {
                        $Schema = new MetadataSchema($SchemaId);
                        foreach ($Schema->getFields() as $Field) {
                            $NormalizedName = strtoupper(
                                preg_replace(
                                    "/[^A-Za-z0-9]/",
                                    "",
                                    $Field->name()
                                )
                            );
                            $FieldNameMappings[$SchemaId][$NormalizedName]
                                = $Field;

                            if ($Field->type() == MetadataSchema::MDFTYPE_USER) {
                                $Key = $NormalizedName.":SMARTNAME";
                                $FieldNameMappings[$SchemaId][$Key]
                                    = $Field;
                            }
                        }
                    }

                    # load standard field name mappings (if not already loaded)
                    if (!isset($StdFieldNameMappings[$SchemaId])) {
                        if (!isset($Schema) || $Schema->id() != $SchemaId) {
                            $Schema = new MetadataSchema($SchemaId);
                        }

                        foreach (MetadataSchema::getStandardFieldNames() as $Name) {
                            $Field = $Schema->getFieldByMappedName($Name);
                            if ($Field !== null) {
                                $NormalizedName = strtoupper(
                                    preg_replace("/[^A-Za-z0-9]/", "", $Name)
                                );

                                $StdFieldNameMappings[$SchemaId][$NormalizedName]
                                    = $Field;
                            }
                        }
                    }

                    # if keyword refers to known field
                    $KeywordIsField = preg_match(
                        "/^FIELD:([A-Z0-9:]+)/",
                        $Matches[1],
                        $SubMatches
                    );
                    $KeywordIsStdField = preg_match(
                        "/^STDFIELD:([A-Z0-9:]+)/",
                        $Matches[1],
                        $StdFieldSubMatches
                    );
                    $KeywordIsShare = preg_match(
                        "/^SHARE:([A-Z]+)/",
                        $Matches[1],
                        $ShareSubMatches
                    );

                    # if there's a match for a field keyword
                    // @codingStandardsIgnoreStart
                    if (($KeywordIsField
                         && isset($FieldNameMappings[$SchemaId][$SubMatches[1]])) ||
                        ($KeywordIsStdField
                         && isset($StdFieldNameMappings[$SchemaId][$StdFieldSubMatches[1]])) )
                    // @codingStandardsIgnoreEnd
                    {
                        # replacement is value from current resource
                        $Field = $KeywordIsField ?
                            $FieldNameMappings[$SchemaId][$SubMatches[1]] :
                            $StdFieldNameMappings[$SchemaId][$StdFieldSubMatches[1]] ;

                        # if this is a User field and the SMARTNAME was requested
                        if ($KeywordIsField &&
                            $Field->Type() == MetadataSchema::MDFTYPE_USER &&
                            preg_match("/:SMARTNAME$/", $SubMatches[1])) {
                            # iterate over the users in this field
                            $Users = $this->KRCurrentResource->Get($Field, true);
                            $Replacement = [];
                            foreach ($Users as $User) {
                                # if they have a real name set, use that
                                # otherwise, fall back to UserName
                                $RealName = trim($User->Get("RealName"));
                                $Replacement[] = (strlen($RealName)) ? $RealName :
                                         $User->Get("UserName") ;
                            }
                        } else {
                            # otherwise just get the value
                            $Replacement = $this->KRCurrentResource->Get($Field);
                        }

                        # combine array values with commas
                        if (is_array($Replacement)) {
                            $Replacement = implode(", ", $Replacement);
                        }

                        if (!$Field->AllowHTML()) {
                            $Replacement = $this->KRIsHtml
                                ? htmlspecialchars($Replacement) : $Replacement;
                            $Replacement = wordwrap($Replacement, 78);
                        } elseif (strpos($Replacement, ">") === false
                                 && strpos($Replacement, "<") === false) {
                            # HTML is allowed but there isn't any HTML in the value,
                            # so wrapping is okay
                            $Replacement = wordwrap($Replacement, 78);
                        } elseif (!$this->KRIsHtml) {
                            # HTML is allowed and in the value but it shouldn't be
                            # used in the plain text version

                            # try as hard as possible to convert the HTML to plain text
                            $Replacement = Email::convertHtmlToPlainText($Replacement);

                            # wrap the body
                            $Replacement = wordwrap($Replacement, 78);
                        }

                        $Replacement = $this->KRIsHtml
                            ? StripXSSThreats($Replacement) : $Replacement;
                    } elseif ($KeywordIsShare
                              && in_array($ShareSubMatches[1], $ShareFormats)) {
                        # if there's a match for a share keyword
                        $Replacement = $this->getShareUrl(
                            $this->KRCurrentResource,
                            $ShareSubMatches[1]
                        );
                    }
                }
                break;
        }

        # return replacement string to caller
        return $Replacement;
    }

    /**
    * Get the share URL for the given resource to the given site, where site is
    * one of "Facebook", "Twitter", or "LinkedIn"
    * @param Record $Resource The resource for which to get the share URL.
    * @param string $Site The site for which to get the share URL.
    * @return string|null Returns the share URL for the resource and site or NULL if the
    *      SocialMedia plugin isn't available.
    */
    private function getShareUrl(Record $Resource, $Site)
    {
        # the social media plugin needs to be available
        if (!$GLOBALS["G_PluginManager"]->PluginEnabled("SocialMedia")) {
            return null;
        }

        # return the share URL from the social media plugin
        $Plugin = $GLOBALS["G_PluginManager"]->getPlugin("SocialMedia");
        return $Plugin->GetShareUrl($Resource, $Site);
    }

    private $SqlTables = [
        "StoredEmails" => "CREATE TABLE IF NOT EXISTS Mailer_StoredEmails (
                Mailer_StoredEmailId       INT NOT NULL AUTO_INCREMENT,
                Mailer_StoredEmailName     TEXT,
                FromAddr                   TEXT,
                ToAddr                     TEXT,
                TemplateId                 INT,
                Email                      LONGBLOB,
                NumResources               INT,
                ResourceIds                BLOB,
                DateCreated                DATETIME,
                INDEX           Index_I (Mailer_StoredEmailId)
            );",
    ];
}
